本系列博客为《深入理解Java虚拟机 - JVM高级特性与最佳实践》读书笔记。本书大量干货,适合初学jvm的人员,也适合为应付面试人员,比较推荐的一本书。本系列只为记录书中精髓,方便查阅与记忆。如有错误,欢迎指出 O(∩_∩)O

基于书中第二章总结,程序计数器、java虚拟机栈、本地方法栈随线程的生命周期创建和销毁,自然不用过多的考虑进行垃圾回收。而java堆、方法区则不一样,这一块的分配和回收是动态的,那本章的内容也是基于这两块的内容。

对象已死吗?

垃圾回收第一步就是要确定那些对象可回收(已死),那些不可回收(活着)。书中介绍两种算法来确定对象是否存活

  • 引用计数法
  • 可达性分析法

引用计数法

引用计数法,是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,则判断为可回收。但是主流jvm都没有使用它,根本原因是它解决不了对象循环引用的问题。

可达性分析法

可达性分析法,是主流语言(java、c#)的主流实现方案。它是通过定义一系列的“GC Root”对象作为起始点,从这些节点往下搜索,搜索所经过的路劲称为引用链。当一个对象没有任何引用链可到达“GC Root”时,则证明该对象不可用,判断为可回收对象。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

再谈引用

这一小节,主要再次细分了引用的类型。java将引用的概念扩充成了:强引用(StrongReference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(PhantomReference)4种,这4种引用强度依次逐渐减弱。

4种引用概念,可以去书中寻找。他们的区别是:
强引用,垃圾收集器永远都不会回收。软引用,在抛出内存溢出之前将这类引用进行回收,在判断是否抛内存溢出异常。弱引用,垃圾收集器会正常就会回收这类引用的对象。虚引用,唯一的目的就是在该对象回收时可以收到一个系统通知。

对象自我拯救(finalize())

在可达性分析法中,宣告一个对象真正死亡,至少要经历两次标记:

  1. 该对象没有与GC Root相连接的引用链,进行第一次标记,并放入F-Queue队列中执行finalize()。
  2. 稍后,GC会对F-Queue队列中的对象进行小范围的标记,这次标记是将在finalize()方法中逃脱对象,移出“即将回收”的集合,剩下的对象就会真正被回收。

注意:finalize()是一个对象逃脱被回收的最后一次机会,如果对象不想被回收,可以重写finalize()之后,将自己(this)赋值给某个变量。finalize()在一个对象生命中,只会执行一次F-Queue队列只会执行重写过的finalize()方法。

回收方法区

方法区(或者HotSpot虚拟机中的永久代)垃圾收集主要回收两部分内容:废弃常量和无用的类。
废弃常量:字符串“abc”已经进入了常量池中,但是此时没有任何一个String引用“abc”。如果有必要的话,则会被系统清理出常量池。
无用的类:该类所有的实例都已经被回收、加载该类的ClassLoader已经被回收、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。满足上述3个条件,则可以对无用类进行回收,而不是必然会回收。

垃圾收集算法

标记-清除算法

标记-清除算法,分两个阶段来完成,一是先标记出需要清理的对象,二是将标记的对象统一回收清除。第一个阶段在上一小节已经描述过了。

标记-清除算法,虽然简单,但是存在两个不足:

  • 效率问题,标记和清除两个过程的效率都不高
  • 空间问题,标记清除会产生大量不连续的内存碎片,当遇到需要分配大对象时,会因为无法找到连续的内存而不得不再一次进行垃圾收集。

标记-清除算法

复制算法

现有的商业虚拟机都采用该种算法来回收新生代。它是将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对整个半区进行回收,内存分配时也不用考虑内存碎片的问题,只需顺序分配即可。这种算法有点就是简单、高效,但是它的代价是内存缩小了一半。

由于新生代98%的对象都是“朝不保夕”,所以不需要按照1:1的比例来划分,而是划分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor空间,回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor上,最后清理掉Eden和刚才使用的Survivor,完成回收动作。

HotSpot虚拟机默认Eden和Survivor比例是8:1,也就是说新生代中能使用的内存为整个空间的90%(80%+10%),只有10%会用于复制算法所需的开销。但是我们没法保证,每次回收都只有不超过10%的对象存活,这时就需要老年代进行分配担保,让这些对象直接进入老年代。

标记-整理算法

复制算法,在存活对象比例较高时,就需要进行较多的复制操作,效率会很低。更关键的时,如果不想浪费50%的空间,需要额外的空间进行分配担保,以应对回收时,100%的对象都存活的极端情况,所以在老年代不会直接使用复制算法。
根据老年代特点,提出标记-整理算法,与标记-清除算法不同的是,第二阶段清除动作,是将所有存活的对象移向一端,然后直接清理掉端边界以外的内存。
标记-清除算法

分代收集算法

分代收集算法,则是为了将内存划分为几块区域,java划分为新生代、老年代。这样可以根据不同的区域选择不同的收集算法,以提高回收效率。新生代对象存活率低,则使用复制算法效率更高,而老年代对象存活率高,又没有额外的空间给它担保,所以就必须使用“标记-整理算法”或者“标记-清除算法”。

HotSpot算法实现

枚举根节点

在GC分析过程中,如果对象引用一直在变化,则最后分析的结果是不准确的,所以GC进行时需要停止所有java执行线程(Sun 将这件事情叫做“Stop The World”)。号称不会发生停顿的CMS收集器中,枚举根节点也必须要停顿的。而现在很多应用仅仅方法区就有数百兆,所有逐一检查这里面的引用,不太现实。

在HotSpot中,使用一组称为OopMap的数据结构来存放对象引用在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。

安全点

在OopMap的协助下,HotSpot可以快速枚举根节点,但是OopMap引用的关系变化,不能每次变化都去维护OopMap,这样代价太大,什么时候去维护呢?这就是安全点(Safepoint)的作用,HotSpot只会在这些地方记录OopMap信息,而程序也只能在这些地方才能停顿下来开始GC。

但是Safepoint也不能过多,因为会增加运行的代价,也不能过少,让GC等待时间太长。一般例如:方法调用、循环跳转、异常跳转等,会产生Safepoint。

当发生GC时,如何让所有线程都跑到就近的安全点上停顿下来,书中讲述两个方案:

  • 抢先式中断,首先让所有线程都中断,如果有线程没有停顿在安全点上,就恢复线程,让他跑到安全点上。几乎没有虚拟机采用该方式
  • 主动式中断,在所有安全点(再增加创建对象分配内存的地方)增加设置标记,当线程执行时,遇到这些标记,就会判断该标记是否需要在当前位置中断自己

安全区域

安全点解决了运行中的线程,但是当线程处于sleep或者blocked状态时,则不能响应jvm的中断请求,这时需要引申安全区域(safe region),安全区域指一段代码片段中,引用关系不会发生变化,可以把safe region透明的看作是safepoint。

当GC发生时,jvm不用处理进入safe region的线程。只有当线程离开safe region时,需要检查GC是否完成根节点枚举,如果已经完成则继续执行线程即可,否则需要等待直到收到可以安全离开safe region的信号为止。

垃圾收集器

垃圾收集器
书中对图中的收集器都做了详细讲解,其中两个收集器之间的连线代表着它们之间可以配合使用。对书中内容先做一个简单总结。

  • Serial收集器,单线程完成垃圾收集工作,收集中需要暂停所有工作线程直到它收集完毕。简单高效,多用于Client模式下新生代收集。
    Serial/Serial Old收集器
  • ParNew收集器,为Serial收集器的多线程版本,其余行为包括控制参数与Serial收集器一致,多用于Server模式下新生代收集。
    ParNew/Serial Old收集器收集器
  • Parallel Scavenge收集器,新生代收集器,复制算法,与上述两个不同的是,它还被称为吞吐量优先收集器,可以设置GC最大停顿时间、以及吞吐量大小参数。
  • Serial Old收集器,老年代收集器,“标记-整理”算法,单线程,用于Client模式下的虚拟机,或者用于Server模式下搭配Parallel Scavenge收集器使用。
    Serial/Serial Old收集器
  • ParNew Old收集器,老年代收集器,“标记-整理”算法,吞吐量优先收集器。在它出现之前Parallel Scavenge只能和Serial Old搭配使用,并不能使用整体应用获取吞吐量最大化,所以有了它。注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old。
    Serial/Serial Old收集器
  • CMS收集器
  • G1收集器

CMS收集器

CMS(Concurrent Mark Sweep),以获取最短回收时间为目标的收集器,用于互联网网站的服务端,采用“标记-清除”算法。其收集过程分为4个步骤

  • 初始标记,仅仅只是枚举GC Root
  • 并发标记,与用户线程同时执行,标记回收对象
  • 重新标记,修改并发标记期间因用户线程继续执行而导致标记产生变动的那一部分对象
  • 并发清除,与用户线程同时执行,回收对象

初始标记、重新标记,依然需要“Stop the world”,但是耗时相比并发标记、并发清除极短。
Concurrent Mark Sweep收集器
CMS缺点也很明显:

  1. 对CPU资源敏感,需要独占一个CPU来执行收集线程。
  2. 无法处理浮动垃圾(即并发标记过程中,用户线程产生的垃圾需要等待到下次收集时处理),所有需要给老年代多预留一部分内存来提前进行GC。jdk1.5 CMS在老年代使用68%时则会启动收集工作,jdk1.6 CMS启动阈值为92%,当收集过程中预留内存不够用,虚拟机将启动后备预案:临时启动Serial Old来重新进行老年代的收集。
  3. CMS采用 “标记-清除”算法,则会产生大量碎片,对大对象分配带来麻烦。CMS收集器提供了一个-XX+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入FullGC时都进行碎片整理)。

G1收集器

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一。优点:并行处理,分代收集,“标记整理”算法,可预测的停顿。G1 其思想是将java堆分为多个Region,回收以Region为单位。使用Remenbered Set来保存各个Region之间的对象引用。当程序对Reference类型的数据进行写操作时,会检查Reference引用的对象是否处于不同的Region之中,如果是,便更新Region所属的Remembered Set。

如果不计算维护Remembered Set,G1收集过程可分为四个阶段

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1收集器

理解GC日志

1
2
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
100.667:[Full GC[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC(System)”。

1
[Full GC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的

  • Serial收集器中的新生代名为“DefaultNew Generation”,所以显示的是“[DefNew”。
  • ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。
  • Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”。

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

总结

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配 [1] ),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

书中对以下几种情况都给出案例,可以去查看

  • 对象优先分配在Eden
  • 大对象直接进入老年代
  • 长期存活对象进入老年代
  • 动态年龄判断
  • 空间分配担保